﻿using System;
using System.Globalization;
using NET = System.Net;
using SOCK = System.Net.Sockets;
using IO = System.IO;
using COL = System.Collections.Generic;

namespace BoresSoft.Ftp
{
	/// <summary>
	/// Main library class.
	/// </summary>
	public class FtpClient : FtpClientRaw
	{
        private ServerSystemType serverType = ServerSystemType.None;

		#region Events

		/// <summary>
		/// Fired when anonymous login failed (if it was allowed) and when code requires login credentials.
		/// </summary>
		public event EventHandler<CredentialRequiredEventArgs> CredentialRequired;

		/// <summary>
		/// Fired after downloading (or uploading) some data.
		/// </summary>
		public event EventHandler<TransmitEventArgs> DownloadProgress;

		#endregion

		/// <summary>
		/// Default constructor.
		/// </summary>
		public FtpClient()
		{
		}

		/// <summary>
		/// Logins to remote FTP server.
		/// </summary>
		/// <param name="anonymous">If set, code will try to login anonymously.</param>
        public void LogOn(bool anonymous)
        {
            if (!Connected)
                throw new InvalidOperationException("Not connected to server.");

            COL.IList<ResponseLine> cmdResult = null;
            if (anonymous)
            {
                cmdResult = SendCommand("USER anonymous");
                if (1 != cmdResult.Count || 331 != cmdResult[0].Code)
                    throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

                cmdResult = SendCommand("PASS ftpclient@boressoft.ru");
            }

			for (int i = 0; null == cmdResult || HasErrors(cmdResult); i++)
			{
				CredentialRequiredEventArgs e = new CredentialRequiredEventArgs(i, false);
				OnCredentialRequired(e);
				if (null == e.Credential || 0 == e.Credential.Length)
					throw new FtpException(ErrorCode.AuthenticationFailed);

				cmdResult = SendCommand("USER " + ClearCommandArg(e.Credential));
				if (1 != cmdResult.Count || 331 != cmdResult[0].Code)
					throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

				e = new CredentialRequiredEventArgs(i, true);
				OnCredentialRequired(e);
				if (null == e.Credential || 0 == e.Credential.Length)
					throw new FtpException(ErrorCode.AuthenticationFailed);

				cmdResult = SendCommand("PASS " + ClearCommandArg(e.Credential));
			}

            // Let's learn system system type and store it
            string sysType = GetSystemType();
			if (sysType.StartsWith("UNIX", StringComparison.Ordinal))
				serverType = ServerSystemType.Unix;
			else if (sysType.Equals("Windows_NT", StringComparison.Ordinal))
				serverType = ServerSystemType.MsDos;
			else
				serverType = ServerSystemType.Unknown;
        }

		/// <summary>
		/// Logoffs ftom remote FTP server.
		/// </summary>
        public void LogOff()
        {
            COL.IList<ResponseLine> cmdResult = SendCommand("QUIT");
            if (1 != cmdResult.Count || 221 != cmdResult[0].Code)
                throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);
        }

		#region Commands

		/// <summary>
		/// Moves one level up in directory tree.
		/// </summary>
        public void ChangeDirectoryUp()
        {
            COL.IList<ResponseLine> cmdResult = SendCommand("CDUP");
            if (1 != cmdResult.Count || cmdResult[0].Code < 200 || cmdResult[0].Code >= 300)
                throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);
        }

		/// <summary>
		/// Changes current directory on remote FTP server to specified one.
		/// </summary>
		/// <param name="dirName">Directory to change to.</param>
        public void ChangeDirectory(string dirName)
        {
            if (null == dirName)
                throw new ArgumentNullException("dirName");
            if (0 == dirName.Length)
                throw new ArgumentException("Directory name not specified.", "dirName");

            COL.IList<ResponseLine> cmdResult = SendCommand("CWD " + ClearCommandArg(dirName));
            if (1 != cmdResult.Count || 250 != cmdResult[0].Code)
                throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);
        }

		/// <summary>
		/// Returns path to the current directory.
		/// </summary>
		/// <returns></returns>
		[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate")]
		public string GetCurrentDirectory()
        {
            COL.IList<ResponseLine> cmdResult = SendCommand("PWD");
            if (1 != cmdResult.Count || 257 != cmdResult[0].Code)
                throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

            string response = cmdResult[0].Message;
            if (0 == response.Length || '"' != response[0])
                throw new FtpException(ErrorCode.InvalidResponse);

            int indx = response.LastIndexOf('"');
            if (indx <= 0)
                throw new FtpException(ErrorCode.InvalidResponse);

            return response.Substring(1, indx - 1);
        }

		/// <summary>
		/// Returns system type string (of remote FTP server);
		/// </summary>
		/// <returns></returns>
		[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate")]
		public string GetSystemType()
        {
            COL.IList<ResponseLine> cmdResult = SendCommand("SYST");
            if (1 != cmdResult.Count || 215 != cmdResult[0].Code)
                throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

            return cmdResult[0].Message;
        }

		/// <summary>
		/// Returns size in bytes of specified file.
		/// </summary>
		/// <param name="fileName">Name of the file on remote FTP server.</param>
		/// <returns>Size of the file in bytes.</returns>
		public long GetFileSize(string fileName)
		{
			if (null == fileName)
				throw new ArgumentNullException("fileName");
			if (0 == fileName.Length)
				throw new ArgumentException("File name not specified.", "fileName");

			COL.IList<ResponseLine> cmdResult = SendCommand("SIZE " + ClearCommandArg(fileName));
			if (1 != cmdResult.Count || 213 != cmdResult[0].Code)
				throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

			try
			{
				// .NET CF does not contain TryParse methods
				return long.Parse(cmdResult[0].Message, NumberStyles.Integer, CultureInfo.InvariantCulture);
			}
			catch (FormatException)
			{
				throw new FtpException(ErrorCode.InvalidResponse, cmdResult);
			}
		}

		/// <summary>
		/// Delete specified file.
		/// </summary>
		/// <param name="fileName">Name of the file on remote FTP server.</param>
		/// <returns>True - if file was deleted, false if file does not exists.</returns>
		public bool DeleteFile(string fileName)
		{
			if (null == fileName)
				throw new ArgumentNullException("fileName");
			if (0 == fileName.Length)
				throw new ArgumentException("File name not specified.", "fileName");

			COL.IList<ResponseLine> cmdResult = SendCommand("DELE " + ClearCommandArg(fileName));
			if (1 != cmdResult.Count)
				throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

			if (550 == cmdResult[0].Code)
				return false;
			else if (HasErrors(cmdResult))
				throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

			return true;
		}

		/// <summary>
		/// Function to be called after reading each portion of data. 
		/// </summary>
		/// <param name="data">Data read from remote server.</param>
		/// <param name="dataLen">Length of data in array.</param>
		/// <param name="arg">Argument to the function.</param>
		/// <returns>True - to continue reading.</returns>
		protected delegate bool DataLoopStepFunc(byte[] data, int dataLen, object arg);

		/// <summary>
		/// Reads data from separate connection and calls function to process it.
		/// </summary>
		/// <param name="cmdResult">Server response from previous command.</param>
		/// <param name="ns">Stream to read from.</param>
		/// <param name="stepAction">Function to call after reading each portion of data.</param>
		/// <param name="actionArg">Argument to be passed to the function.</param>
		protected void DataLoop(COL.IList<ResponseLine> cmdResult, SOCK.NetworkStream ns, DataLoopStepFunc stepAction, object actionArg)
		{
			// Wait for end of transmission
			bool cont = true;
			while (cont && !AnyResponseBetween(cmdResult, 200, 299))
			{
				cmdResult = SendCommand(false);
				if (HasErrors(cmdResult))
					throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

				cont = DataLoopStep(ns, stepAction, actionArg);
			}

			// Read rest of the data
			if (cont)
				DataLoopStep(ns, stepAction, actionArg);
		}

		private static bool DataLoopStep(SOCK.NetworkStream ns, DataLoopStepFunc stepAction, object actionArg)
		{
			if (ns.DataAvailable)
			{
				byte[] buff = new byte[4096];
				int bytesRead = 0;
				while ((bytesRead = ns.Read(buff, 0, buff.Length)) > 0)
				{
					if (!stepAction(buff, bytesRead, actionArg))
						return false;
				}
			}

			return true;
		}

		/// <summary>
		/// Returns remote files system object in current directory.
		/// </summary>
		/// <returns>Remote file system objects.</returns>
        public COL.IEnumerable<ItemInfo> List()
        {
            SOCK.TcpClient pasvClient = null;
			SOCK.NetworkStream ns = null;

            try
            {
				// We should use separate connection for file listing
                pasvClient = ConnectPassive();

				ServerSystemType listingType = ServerSystemType.None; // None means MLSD
                COL.IList<ResponseLine> cmdResult = SendCommand("MLSD");
                if (HasErrors(cmdResult))
                { // MLSD is not supported. Try system-specific listing.
					cmdResult = SendCommand("LIST");
					listingType = serverType;
                }

				string restBuff = string.Empty;
				ns = pasvClient.GetStream();
				COL.List<ItemInfo> retVal = new COL.List<ItemInfo>(64);

				DataLoop(
					cmdResult,
					ns,
					delegate(byte[] data, int dataLen, object arg)
					{
						restBuff += System.Text.Encoding.ASCII.GetString(data, 0, dataLen);

						int indx = (-1);
						while ((indx = restBuff.IndexOf("\r\n", StringComparison.Ordinal)) > 0) // Must not begin with newline
						{
							retVal.Add(
								new ItemInfo(restBuff.Substring(0, indx), listingType)
							);
							restBuff = restBuff.Substring(indx + 2);
						}

						return true;
					},
					null
				);
	
                return retVal;
            }
            finally
            {
				if (null != ns)
					ns.Close();
				if (null != pasvClient)
					pasvClient.Close();
            }
        }

		/// <summary>
		/// Downloads remote file to a stream object.
		/// </summary>
		/// <param name="fileName">Name of the file on remote server.</param>
		/// <param name="destStream">Stream to write file data to.</param>
		/// <returns>Bytes written to the stream.</returns>
		public long Download(string fileName, IO.Stream destStream)
		{
			if (null == fileName)
				throw new ArgumentNullException("fileName");
			if (0 == fileName.Length)
				throw new ArgumentException("File name must be specified.", "fileName");
			if (null == destStream)
				throw new ArgumentNullException("destStream");

			SOCK.TcpClient pasvClient = null;
			SOCK.NetworkStream ns = null;

			try
			{
				// For big transmissions - always separate connection
				pasvClient = ConnectPassive();

				COL.IList<ResponseLine> cmdResult = SendCommand("RETR " + ClearCommandArg(fileName));
				ns = pasvClient.GetStream();
				long readLen = 0;

				DataLoop(
					cmdResult,
					ns,
					delegate(byte[] data, int dataLen, object arg)
					{
						destStream.Write(data, 0, dataLen);
						readLen += dataLen;
						OnDownloadProgress(new TransmitEventArgs(readLen));
						return true;
					},
					null
				);

				return readLen;
			}
			finally
			{
				if (null != ns)
					ns.Close();
				if (null != pasvClient)
					pasvClient.Close();
			}
		}

		/// <summary>
		/// Called while downloading after some data is read.
		/// </summary>
		/// <param name="e"></param>
		protected virtual void OnDownloadProgress(TransmitEventArgs e)
		{
			if (null != DownloadProgress)
				DownloadProgress(this, e);
		}

		/// <summary>
		/// Downloads remote file to local file.
		/// </summary>
		/// <param name="fileName">Name of the file on remote server.</param>
		/// <param name="localFileName">Name of local file or directory.</param>
		/// <returns>Bytes written to the file.</returns>
		public long Download(string fileName, string localFileName)
		{
			// fileName will be checked in Download(string)
			if (null == localFileName)
				throw new ArgumentNullException("localFileName");
			if (0 == localFileName.Length)
				throw new ArgumentException("Local file name must be specified.", "localFileName");

			IO.FileStream fs = null;
			try
			{
				if (IO.Directory.Exists(localFileName))
					localFileName += IO.Path.DirectorySeparatorChar + fileName;

				fs = new IO.FileStream(localFileName, IO.FileMode.Create, IO.FileAccess.Write);
				return Download(fileName, fs);
			}
			finally
			{
				if (null != fs)
					fs.Close();
			}
		}

		/// <summary>
		/// Uploads data from stream to remote server with specified name.
		/// </summary>
		/// <param name="fileName">File name on the remote server.</param>
		/// <param name="srcStream">Stream with data to upload.</param>
		public void Upload(string fileName, IO.Stream srcStream)
		{
			if (null == fileName)
				throw new ArgumentNullException("fileName");
			if (0 == fileName.Length)
				throw new ArgumentException("File name must be specified.", "fileName");
			if (null == srcStream)
				throw new ArgumentNullException("srcStream");

			SOCK.TcpClient pasvClient = null;
			SOCK.NetworkStream ns = null;
			COL.IList<ResponseLine> cmdResult = null;

			try
			{
				// For big transmissions - always separate connection
				pasvClient = ConnectPassive();

				cmdResult = SendCommand("STOR " + ClearCommandArg(fileName));
				ns = pasvClient.GetStream();

				byte[] buff = new byte[4096];
				int bytesRead = 0;
				while ((bytesRead = srcStream.Read(buff, 0, buff.Length)) > 0)
				{
					ns.Write(buff, 0, bytesRead);

					if (bytesRead < buff.Length)
						break;
				}
			}
			finally
			{
				if (null != ns)
					ns.Close();
				if (null != pasvClient)
					pasvClient.Close();
			}

			cmdResult = SendCommand(true);
			if (HasErrors(cmdResult))
				throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);
		}

		/// <summary>
		/// Uploads specified file to remote server with specified name.
		/// </summary>
		/// <param name="fileName">File name on the remote server.</param>
		/// <param name="localFileName">Path to the local file.</param>
		public void Upload(string fileName, string localFileName)
		{
			// fileName will be checked in Upload(string)
			if (null == localFileName)
				throw new ArgumentNullException("localFileName");
			if (0 == localFileName.Length)
				throw new ArgumentException("Local file name must be specified.", "localFileName");

			IO.FileStream fs = null;
			try
			{
				fs = new IO.FileStream(localFileName, IO.FileMode.Open, IO.FileAccess.Read);
				Upload(fileName, fs);
			}
			finally
			{
				if (null != fs)
					fs.Close();
			}
		}

		/// <summary>
		/// Uploads specified file to current directory on remote server with the same name.
		/// </summary>
		/// <param name="localFileName">Path to the local file to upload.</param>
		public void Upload(string localFileName)
		{
			if (null == localFileName)
				throw new ArgumentNullException("localFileName");
			if (0 == localFileName.Length)
				throw new ArgumentException("Local file name must be specified.", "localFileName");

			Upload(IO.Path.GetFileName(localFileName), localFileName);
		}

		#endregion

		protected virtual void OnCredentialRequired(CredentialRequiredEventArgs e)
		{
			if (null != CredentialRequired)
				CredentialRequired(this, e);
		}

		/// <summary>
		/// Removes unallowed symbols from FTP command arguments.
		/// </summary>
		/// <param name="arg">FTP command argument.</param>
		/// <returns>Argumens with unallowed symbols removed.</returns>
        protected static string ClearCommandArg(string arg)
        {
            if (null == arg || 0 == arg.Length)
                return arg;

            string retVal = arg;
            char[] invalidChars = new char[] { '\r', '\n', '\0' };
            foreach (char c in invalidChars)
                retVal = retVal.Replace(c, '_');

            return retVal;
        }

        protected static bool HasErrors(COL.IEnumerable<ResponseLine> cmdResult)
        {
            if (null == cmdResult)
                throw new ArgumentNullException("cmdResult");

			return AnyResponseBetween(cmdResult, 400, 599);
        }

		/// <summary>
		/// Makes passive FTP connection to a remote FTP server.
		/// </summary>
		/// <returns>TcpClient for new connection.</returns>
        protected SOCK.TcpClient ConnectPassive()
        {
            COL.IList<ResponseLine> cmdResult = SendCommand("PASV");
            if (1 != cmdResult.Count || 227 != cmdResult[0].Code)
                throw new FtpException(ErrorCode.UnexpectedResponseCode, cmdResult);

            string response = cmdResult[0].Message.Trim(); // Remove line brake at the end
            if (0 == response.Length)
                throw new FtpException(ErrorCode.InvalidResponse);

            int indxStart = response.IndexOf('('), indxEnd = response.IndexOf(')');
            if (indxStart < 0 || indxEnd < 0 || indxStart >= indxEnd)
                throw new FtpException(ErrorCode.InvalidResponse);

            string[] arr = response.Substring(indxStart + 1, indxEnd - indxStart - 1).Split(new char[] { ',' });
            if (6 != arr.Length)
                throw new FtpException(ErrorCode.InvalidResponse);

			byte[] arrByte = new byte[arr.Length];
			for (int i = 0; i < arr.Length; i++)
				arrByte[i] = byte.Parse(arr[i], NumberStyles.Integer, CultureInfo.InvariantCulture);

            NET.IPEndPoint ipEp = new NET.IPEndPoint(
                ((long)arrByte[3] << 24) | ((long)arrByte[2] << 16) | ((long)arrByte[1] << 8) | (long)arrByte[0],
                (arrByte[4] << 8) | arrByte[5]
            );
            SOCK.TcpClient retVal = new SOCK.TcpClient();
            retVal.Connect(ipEp);
            return retVal;
        }

		protected static bool AllResponsesBetween(COL.IEnumerable<ResponseLine> cmdResult, short minCode, short maxCode)
		{
			foreach (ResponseLine rl in cmdResult)
			{
				if (rl.Code < minCode || rl.Code > maxCode)
					return false;
			}

			return true;
		}

		protected static bool AnyResponseBetween(COL.IEnumerable<ResponseLine> cmdResult, short minCode, short maxCode)
		{
			foreach (ResponseLine rl in cmdResult)
			{
				if (rl.Code >= minCode && rl.Code <= maxCode)
					return true;
			}

			return false;
		}
	}
}
